task group,即所谓的任务组调度,旨在解决指定的一组任务如何做CPU带宽控制的问题。
为什么需要对一组任务做带宽控制?或者说什么场景需要这种能力?
典型的应用就是容器场景,不同业务的容器运行在同一物理机上,通过CPU带宽控制可实现一定程度的容器之间的资源隔离性。有了带宽控制,我们也可以对不同租户基于带宽进行差异化收费。。。
了解其背景后,我们再来看下task group是怎么实现带宽控制的。
数据结构 在前面介绍调度框架 的时候,我们已经对task group的数据结构有了初步的了解,这里再简单讲下。
已知一个task group通常具有多个任务,每个任务可能运行在不同的cpu上,为了便于管理,在struct task_group
中分别声明了两个变量:**se
和**cfs_rq
,二者本质上都是数组,根据cpu index可以追踪到对应cpu上属于该task group的se和cfs_rq,比如:*se[0]
对应的就是cpu0上的se。这两个变量的具体作用需切换到单cpu视角上来看。
一个cpu上可能存在多个任务正在运行,其中这些任务有一部分属于某个task group,那么对于这一部分任务,为便于追踪,避免低效的任务遍历,调度设计上使用了一个私有的cfs_rq容纳,这样只要通过**cfs_rq[i]
就可以pick出该task group在某cpu上的所有任务子集了。
但现在又面临了一个问题:已知cpu上的调度是通过一个rq队列维护,现在新增了一个私有的cfs_rq队列,要如何参与到该cpu的调度里去?这就引入了一个特殊的se:group se。该se作为私有cfs_rq的代表,加入到cpu调度队列。当该group se被pick时,继续下钻到私有cfs_rq内pick;当进行vruntime统计时,group se汇总私有cfs_rq内的所有任务vruntime并上报。这样就完成了调度操作的闭环。
group se不需要新定义,只需对普通se做一个小小的扩展,使其能够追踪到task group在该cpu上私有的cfs_rq。对于vruntime等统计信息,普通se本身就已携带,故无需改动。另外,为了方便私有cfs_rq内的任务能够快速追踪到group se,定义了一个parent
字段,在后面我们也将看到该字段是用于带宽控制遍历的一个关键。完整的group se扩展如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 struct sched_entity { #ifdef CONFIG_FAIR_GROUP_SCHED int depth; struct sched_entity *parent ; struct cfs_rq *cfs_rq ; struct cfs_rq *my_q ; unsigned long runnable_weight; #endif }
pick任务时顺着my->q
字段不断往下追溯,直到找到普通的se(也就是具体的任务)执行:
1 2 3 4 5 6 7 pick_next_task_fair pick_task_fair do { se = pick_next_entity(rq, cfs_rq); cfs_rq = group_cfs_rq(se); } while (cfs_rq);
最后,我们将所有cpu上的group se汇总,就得到了前面所述task group的**se
数组。
cgroup带宽控制参数 带宽控制是task group的主要课题。因为task group对应的是cgroup的CPU子系统,所以本节将从cgroup的带宽控制参数切入,先对带宽控制能力有一个直观感受。
带宽控制的预期应该是什么样的?
以下图为例,假设一个任务运行完成需要200ms,如果不受带宽限制,则将满载运行;如果做了带宽控制,则将会是走走停停:
为了达成这样的效果,cgroup引入了cpu.max文件。
cpu.max cpu.max中有两个值,分别对应到quota和period,单位us,表示一个period周期内该cgroup最多可使用quota的时间。上图中period即为100ms,quota为40ms。
cgroup分v1和v2版本,cpu.max是v2版本的文件,对应到v1里的cpu.cfs_period_us和cpu.cfs_quota_us。其他详细差异可以查询该文档 。
如果设置quota / period = 0.5,则表示当前cgroup可用cpu核为0.5个,在cgroup满载时top看到的占用率会是50%;如果设置quota / period = 2,则表示可用的cpu核为2个,在cgroup满载时top看到的占用率将会是200%。一个cgroup里如果有多个任务满载运行,则这些任务将平分50%或200%的cpu占用率。
quota和period分别对应到task group中的quota和period属性,和带宽控制相关的参数统一由struct cfs_bandwidth
组织:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { .name = "max" , .flags = CFTYPE_NOT_ON_ROOT, .seq_show = cpu_max_show, .write = cpu_max_write, }, cpu_max_show tg_get_cfs_period tg->cfs_bandwidth.period tg_get_cfs_quota tg->cfs_bandwidth.quota
task group和cfs_bandwidth数据结构如下:
1 2 3 4 5 6 7 8 9 10 struct task_group { struct cgroup_subsys_state css ; struct cfs_bandwidth cfs_bandwidth ; }; struct cfs_bandwidth { ktime_t period; u64 quota; };
cpu.weight和cpu.weight.nice cpu.weight和cpu.weight.nice用于调整cgroup的可用cpu权重。该权重在以下场景不会生效:
系统中只有1个cgroup时
系统尚未达到100%满载时
只有在系统达到100%满载时,task group才会基于权重考虑应该给各个cgroup分配多少比例的cpu。举个简单的例子:
假设系统有8个cpu,系统中有两个cgroup,cpu.weight分别配置为100和300,quota/period分别配置8个核,现在对两个cgroup进行满压(比如跑8个stress任务:stress -c 8
),这种场景下,将看到cgroup之间的cpu比值将为1:3。
而如果我们将quota/period配置为4个核,cpu.weight权重不变,则两个cgroup满压时,二者的cpu比值则为1:1。因为在考虑权重前,两个cgroup的可用cpu先被quota/period受限,二者可用核数相加未超过系统总cpu数。
因此,触发cpu.weight的条件可归纳为: $$ \sum_{i=1}^{N}\frac{quota_i}{period_i} > M $$ 其中N为cgroup数量,M为系统可用cpu核数。
cpu.weight.nice值和cpu.weight是联动的:
1 2 3 4 5 6 7 8 9 root@ubuntu-server:/sys/fs/cgroup/test 100 root@ubuntu-server:/sys/fs/cgroup/test 0 root@ubuntu-server:/sys/fs/cgroup/test root@ubuntu-server:/sys/fs/cgroup/test 300 root@ubuntu-server:/sys/fs/cgroup/test -5
cpu.weight.nice实际上是基于cpu.weight寻找最匹配的nice值的结果。
cpu.weight和cpu.weight.nice值根据task group的shares属性读取:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 { .name = "weight.nice" , .flags = CFTYPE_NOT_ON_ROOT, .read_s64 = cpu_weight_nice_read_s64, .write_s64 = cpu_weight_nice_write_s64, }, static s64 cpu_weight_nice_read_s64(struct cgroup_subsys_state *css, struct cftype *cft) { unsigned long weight = tg_weight(css_tg(css)); int last_delta = INT_MAX; int prio, delta; for (prio = 0 ; prio < ARRAY_SIZE(sched_prio_to_weight); prio++) { delta = abs (sched_prio_to_weight[prio] - weight); if (delta >= last_delta) break ; last_delta = delta; } return PRIO_TO_NICE(prio - 1 + MAX_RT_PRIO); }
cpu.max.burst cpu.max.burst是指cgroup在单个period周期内除了quota配额外可预支未来多少时间。该参数的引入初衷是为了解决业务存在不定期突发请求且业务时延敏感的问题。这种业务通常在很长的一段时间内空闲,quota充足,但又存在不定期的突发,单个period的quota很快被耗尽,触发带宽控制,导致请求需等到下一个period重新分配quota时才能够继续运行,影响了请求时延。有了burst后,就容许请求在单period内运行超出quota的时间,确保请求在单period内处理完毕,无需等待。
burst参数对应到task group内的burst字段:
1 2 3 4 struct cfs_bandwidth { ... u64 burst; };
实际在充值时,burst基于如下规则分配给cgroup可用时间:
1 2 3 __refill_cfs_bandwidth_runtime cfs_b->runtime += cfs_b->quota; cfs_b->runtime = min(cfs_b->runtime, cfs_b->quota + cfs_b->burst);
quota消耗、充值和再分配 quota消耗 quota消耗的调用栈如下所示:
调用入口有很多,可以认为只要任务在运行,就会定期检查和消耗quota;
多个调用入口最终都汇聚到__assign_cfs_rq_runtime
函数,该函数有一个sched_cfs_bandwidth_slice()
入参,对应到kernel.sched_cfs_bandwidth_slice_us
内核参数,表示每次任务耗尽本地可用时间时可补充多大的时间片。默认情况下该参数为5ms;
runtime_remaining
挂载在cfs_rq,该cfs_rq对应的即为task group在某cpu上的私有cfs_rq,说明runtime_remaining
是一个per-cpu的,该cfs_rq里的任务运行时间均从cfs_rq->runtime_remaining
扣。
1 2 3 4 5 6 7 8 update_curr/set_next_task_fair/enqueue_entity/... account_cfs_rq_runtime __account_cfs_rq_runtime cfs_rq->runtime_remaining -= delta_exec; if (likely(cfs_rq->runtime_remaining > 0 )) return ; assign_cfs_rq_runtime(cfs_rq) __assign_cfs_rq_runtime(cfs_b, cfs_rq, sched_cfs_bandwidth_slice());
__assign_cfs_rq_runtime
函数实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 static int __assign_cfs_rq_runtime(struct cfs_bandwidth *cfs_b, struct cfs_rq *cfs_rq, u64 target_runtime) { u64 min_amount, amount = 0 ; lockdep_assert_held(&cfs_b->lock); min_amount = target_runtime - cfs_rq->runtime_remaining; if (cfs_b->quota == RUNTIME_INF) amount = min_amount; else { start_cfs_bandwidth(cfs_b); if (cfs_b->runtime > 0 ) { amount = min(cfs_b->runtime, min_amount); cfs_b->runtime -= amount; cfs_b->idle = 0 ; } } cfs_rq->runtime_remaining += amount; return cfs_rq->runtime_remaining > 0 ; }
现在我们已经看到了两个属性:cfs_b->runtime
和cfs_rq->runtime_remaining
。总结下:
cfs_b->runtime
:对应到全局剩余可用时间,quota充值到这里
cfs_rq->runtime_remaining
:对应到本地per-cpu剩余可用时间,从全局cfs_b->runtime
处按每次5ms(由kernel.sched_cfs_bandwidth_slice_us
决定)的时间片申请。
quota充值 quota充值调用栈如下所示。可以看到充值是通过period timer触发的,这也就符合前文所述的按照period周期限制quota时间的机制:
1 2 3 4 sched_cfs_period_timer do_sched_cfs_period_timer __refill_cfs_bandwidth_runtime
__refill_cfs_bandwidth_runtime
函数实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 void __refill_cfs_bandwidth_runtime(struct cfs_bandwidth *cfs_b){ s64 runtime; if (unlikely(cfs_b->quota == RUNTIME_INF)) return ; cfs_b->runtime += cfs_b->quota; ... cfs_b->runtime = min(cfs_b->runtime, cfs_b->quota + cfs_b->burst); }
quota再分配 全局quota完成充值后,需重新分配给各个cpu上,这样挂起的任务才能继续运行。
分配函数为distribute_cfs_runtime
,其实现如下所示。大体过程可以描述为:
遍历挂起列表,列表里每个cfs_rq透支的时间补齐
对已补齐时间的cfs_rq依次解挂
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 static bool distribute_cfs_runtime (struct cfs_bandwidth *cfs_b) { int this_cpu = smp_processor_id(); u64 runtime, remaining = 1 ; bool throttled = false ; struct cfs_rq *cfs_rq , *tmp ; struct rq_flags rf ; struct rq *rq ; LIST_HEAD(local_unthrottle); rcu_read_lock(); list_for_each_entry_rcu(cfs_rq, &cfs_b->throttled_cfs_rq, throttled_list) { rq = rq_of(cfs_rq); if (!remaining) { throttled = true ; break ; } ... raw_spin_lock(&cfs_b->lock); runtime = -cfs_rq->runtime_remaining + 1 ; if (runtime > cfs_b->runtime) runtime = cfs_b->runtime; cfs_b->runtime -= runtime; remaining = cfs_b->runtime; raw_spin_unlock(&cfs_b->lock); cfs_rq->runtime_remaining += runtime; if (cfs_rq->runtime_remaining > 0 ) { if (cpu_of(rq) != this_cpu) { unthrottle_cfs_rq_async(cfs_rq); } else { SCHED_WARN_ON(!list_empty(&local_unthrottle)); list_add_tail(&cfs_rq->throttled_csd_list, &local_unthrottle); } } else { throttled = true ; } next: rq_unlock_irqrestore(rq, &rf); } list_for_each_entry_safe(cfs_rq, tmp, &local_unthrottle, throttled_csd_list) { struct rq *rq = rq_of(cfs_rq); rq_lock_irqsave(rq, &rf); list_del_init(&cfs_rq->throttled_csd_list); if (cfs_rq_throttled(cfs_rq)) unthrottle_cfs_rq(cfs_rq); rq_unlock_irqrestore(rq, &rf); } ... }
throttle和unthrottle throttle挂起 当全局quota耗尽时,表示task group在本周期内没有可用cpu,因此需将task group内的任务挂起不执行,达成throttle的效果。
具体实现见throttle_cfs_rq
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 static bool throttle_cfs_rq (struct cfs_rq *cfs_rq) { se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))]; for_each_sched_entity(se) { struct cfs_rq *qcfs_rq = cfs_rq_of(se); if (!se->on_rq) goto done; dequeue_entity(qcfs_rq, se, flags); ... } for_each_sched_entity(se) { struct cfs_rq *qcfs_rq = cfs_rq_of(se); if (!se->on_rq) goto done; update_load_avg(qcfs_rq, se, 0 ); ... } done: cfs_rq->throttled = 1 ; }
什么时候会触发挂起?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 enqueue_entity check_enqueue_throttle throttle_cfs_rq pick_next_task_fair pick_task_fair check_cfs_rq_runtime throttle_cfs_rq put_prev_task_fair put_prev_entity check_cfs_rq_runtime throttle_cfs_rq
unthrottle解挂 解除挂起的流程基本和前面挂起流程反着来,具体实现位于unthrottle_cfs_rq
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void unthrottle_cfs_rq (struct cfs_rq *cfs_rq) { se = cfs_rq->tg->se[cpu_of(rq)]; cfs_rq->throttled = 0 ; for_each_sched_entity(se) { struct cfs_rq *qcfs_rq = cfs_rq_of(se); if (se->on_rq) break ; enqueue_entity(qcfs_rq, se, ENQUEUE_WAKEUP); } for_each_sched_entity(se) { struct cfs_rq *qcfs_rq = cfs_rq_of(se); update_load_avg(qcfs_rq, se, UPDATE_TG); ... } ... }
什么时候会发解挂?
1 2 3 4 5 6 7 8 destroy_cfs_bandwidth __cfsb_csd_unthrottle distribute_cfs_runtime __unthrottle_cfs_rq_async unthrottle_cfs_rq
period timer和slack timer period timer 前面已提到period timer决定了什么时间充值quota,具体timer回调函数实现如下:
1 2 3 4 5 6 7 sched_cfs_period_timer for (;;) { overrun = hrtimer_forward_now(timer, cfs_b->period); if (!overrun) break ; idle = do_sched_cfs_period_timer(cfs_b, overrun, flags);
在“quota消耗”章节中,我们看到为本地runtime_remaining充值时有一个start_cfs_bandwidth
调用,该调用即为确保period timer总是激活:
1 2 3 4 5 6 7 8 9 10 11 void start_cfs_bandwidth (struct cfs_bandwidth *cfs_b) { lockdep_assert_held(&cfs_b->lock); if (cfs_b->period_active) return ; cfs_b->period_active = 1 ; hrtimer_forward_now(&cfs_b->period_timer, cfs_b->period); hrtimer_start_expires(&cfs_b->period_timer, HRTIMER_MODE_ABS_PINNED); }
slack timer period timer已经能够周期性充值了,为什么还需要另一个slack timer?
考虑这样的一个场景:
CPU0从全局quota申请了5ms,未消耗完,有剩余;CPU1也从全局quota申请了5ms,但由于全局quota耗尽,CPU1只申请到了1ms,用完且还不够(此时CPU1上的任务被unthrottle挂起)。时间片是宝贵的,为了最大化利用,尝试让CPU0将剩余时间归还到全局quota,然后CPU1再申请,从而榨干最后一点时间。
为了解决这样的场景,引入了slack timer。
slack timer的触发调用栈如下所示:
1 2 3 4 5 dequeue_entity return_cfs_rq_runtime __return_cfs_rq_runtime start_cfs_slack_bandwidth
具体看下__return_cfs_rq_runtime
和start_cfs_slack_bandwidth
的实现。
__return_cfs_rq_runtime
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static void __return_cfs_rq_runtime(struct cfs_rq *cfs_rq){ struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg); s64 slack_runtime = cfs_rq->runtime_remaining - min_cfs_rq_runtime; if (slack_runtime <= 0 ) return ; raw_spin_lock(&cfs_b->lock); if (cfs_b->quota != RUNTIME_INF) { cfs_b->runtime += slack_runtime; if (cfs_b->runtime > sched_cfs_bandwidth_slice() && !list_empty(&cfs_b->throttled_cfs_rq)) start_cfs_slack_bandwidth(cfs_b); } raw_spin_unlock(&cfs_b->lock); cfs_rq->runtime_remaining -= slack_runtime; }
start_cfs_slack_bandwidth
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static void start_cfs_slack_bandwidth (struct cfs_bandwidth *cfs_b) { u64 min_left = cfs_bandwidth_slack_period + min_bandwidth_expiration; if (runtime_refresh_within(cfs_b, min_left)) return ; if (cfs_b->slack_started) return ; cfs_b->slack_started = true ; hrtimer_start(&cfs_b->slack_timer, ns_to_ktime(cfs_bandwidth_slack_period), HRTIMER_MODE_REL); }
那slack timer具体做什么事呢?其回调函数为sched_cfs_slack_timer
,主要是调用distribute_cfs_runtime
对全局可用时间进行再分配:
1 2 3 4 sched_cfs_slack_timer do_sched_cfs_slack_timer distribute_cfs_runtime
实际案例 以下是一个实际案例,清晰展示了前文所述的带宽控制过程。
假设系统有2个cpu,quota为20ms,period为100ms,下图呈现了全局quota(对应cfs_b->runtime
)和per-cpu quota(对应cfs_rq->runtime_remaining
)的剩余情况:
CPU1申请了5ms,运行worker1,刚好用完,worker1主动休眠;
CPU2申请了5ms,运行worker2,刚好用完,worker2主动休眠;
CPU1申请了5ms,运行worker1,只跑了1ms,剩余4ms未使用;
过了7ms后,slack timer触发,CPU1将剩余4ms的归还(这里只归还了3ms,自己留了1ms)
CPU2申请了5ms,运行worker2,用完,还不够;
CPU2再次想要申请5ms,但此时全局quota只剩3ms了,所以CPU2只申请到了3ms,用完;
至此CPU2上的任务被throttle挂起;
而CPU1上因为保留了1ms,所以未被挂起。
以上就是整个task group带宽控制的全过程。
参考
组调度和带宽控制